// Copyright 2023 Terramate GmbH
// SPDX-License-Identifier: MPL-2.0

package test

import (
	"errors"
	"fmt"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/madlambda/spells/assert"
	hhcl "github.com/terramate-io/hcl/v2"
	"github.com/terramate-io/hcl/v2/hclwrite"
	"github.com/terramate-io/terramate/config"
	"github.com/terramate-io/terramate/generate/report"
	"github.com/terramate-io/terramate/hcl"
	"github.com/terramate-io/terramate/hcl/ast"
	"github.com/terramate-io/terramate/hcl/info"
	"github.com/terramate-io/terramate/project"
	errtest "github.com/terramate-io/terramate/test/errors"
	"golang.org/x/exp/slices"
)

// ParseTerramateConfig parses the Terramate configuration found
// on the given dir, returning the parsed configuration.
func ParseTerramateConfig(t *testing.T, dir string) *hcl.Config {
	t.Helper()

	parser, err := hcl.NewTerramateParser(dir, dir)
	assert.NoError(t, err)

	err = parser.AddDir(dir)
	assert.NoError(t, err)

	cfg, err := parser.ParseConfig()
	assert.NoError(t, err)

	return cfg
}

// AssertGenCodeEquals checks if got gen code equals want. Since got
// is generated by Terramate it will be stripped of its Terramate
// header (if present) before comparing with want.
func AssertGenCodeEquals(t *testing.T, got string, want string) {
	t.Helper()

	const trimmedChars = "\n "

	// Terramate header validation is done separately, here we check only code.
	// So headers are removed.
	got = removeTerramateHCLHeader(got)
	got = strings.Trim(got, trimmedChars)

	if diff := cmp.Diff(want, got); diff != "" {
		t.Error("generated code doesn't match expectation")
		t.Errorf("want:\n%q", want)
		t.Errorf("got:\n%q", got)
		t.Fatalf("diff:\n%s", diff)
	}
}

// AssertTerramateConfig checks if two given Terramate configs are equal.
func AssertTerramateConfig(t *testing.T, got, want *hcl.Config) {
	t.Helper()

	// Reasoning:
	// It happened several times that fields were added to the
	// hcl.Config tree and their respective assertXYZ() func
	// was not updated to include the field in the assertion,
	// leading to false positive test cases.
	// The solution here for the aforementioned problem is not
	// ideal but can potentially catch more mistakes like that.
	//
	// We run an initial cmp.Diff() over the entire hcl.Config{}
	// and ignore the non-comparable fields. If a new field is
	// introduced into a non-ignored type, then it will be compared
	// by default. We must be very selective on what we ignore,
	// in order to make this strategy work.

	if diff := cmp.Diff(got, want,
		cmpopts.IgnoreUnexported(hcl.Config{}),
		cmpopts.IgnoreUnexported(project.Path{}),

		// this contains the Raw HCL constructs and it was never tested here.
		cmpopts.IgnoreFields(hcl.Config{}, "Imported"),

		// Globals/Asserts/Scripts are mostly Attribute and Expr, which cannot be easily compared with cmp.Diff.
		cmpopts.IgnoreFields(hcl.Config{}, "Globals", "Asserts", "Scripts", "Inputs", "Outputs"),
		cmpopts.IgnoreFields(hcl.RunEnv{}, "Attributes"), // because Expr and Range
		cmpopts.IgnoreFields(hcl.Config{}, "Generate"),
	); diff != "" {
		t.Logf("want: %+v", want)
		t.Logf("got: %+v", got)
		t.Fatal(diff)
	}

	assertTerramateBlock(t, got.Terramate, want.Terramate)
	assertAssertsBlock(t, got.Asserts, want.Asserts, "terramate asserts")
	assertGenHCLBlocks(t, got.Generate.HCLs, want.Generate.HCLs)
	assertGenFileBlocks(t, got.Generate.Files, want.Generate.Files)
	assertScriptBlocks(t, got.Scripts, want.Scripts)
}

// AssertDiff will compare the two values and fail if they are not the same
// providing a comprehensive textual diff of the differences between them.
// If provided msg must be a string + any formatting parameters. The msg will be
// added if the assertion fails.
func AssertDiff(t *testing.T, got, want interface{}, msg ...interface{}) {
	t.Helper()

	if diff := cmp.Diff(got, want, cmp.AllowUnexported(project.Path{})); diff != "" {
		errmsg := fmt.Sprintf("-(got) +(want):\n%s", diff)
		if len(msg) > 0 {
			errmsg = fmt.Sprintf(msg[0].(string), msg[1:]...) + ": " + errmsg
		}
		t.Fatal(errmsg)
	}
}

// NewExpr parses the given string and returns a hcl.Expression.
func NewExpr(t *testing.T, expr string) hhcl.Expression {
	t.Helper()

	res, err := ast.ParseExpression(expr, "test")
	assert.NoError(t, err)
	return res
}

// AssertConfigEquals asserts that two [config.Assert] are equal.
func AssertConfigEquals(t *testing.T, got, want []config.Assert) {
	t.Helper()

	if len(got) != len(want) {
		t.Fatalf("got %d assert blocks, want %d", len(got), len(want))
	}

	for i, g := range got {
		w := want[i]
		if g.Assertion != w.Assertion {
			t.Errorf("got.Assertion[%d]=%t, want=%t", i, g.Assertion, w.Assertion)
		}
		if g.Warning != w.Warning {
			t.Errorf("got.Warning[%d]=%t, want=%t", i, g.Warning, w.Warning)
		}
		AssertDiff(t, g.Range, w.Range, "range mismatch")
		assert.EqualStrings(t, w.Message, g.Message, "message mismatch")
	}
}

// AssertEqualPos checks if two ast.Pos are equal.
func AssertEqualPos(t *testing.T, got, want info.Pos, fmtargs ...any) {
	t.Helper()

	msg := prefixer(fmtargs...)

	assert.EqualInts(t, want.Line(), got.Line(), msg("line mismatch"))
	assert.EqualInts(t, want.Column(), got.Column(), msg("column mismatch"))
	assert.EqualInts(t, want.Byte(), got.Byte(), msg("byte mismatch"))
}

// AssertEqualRanges checks if two ranges are equal.
// If the wanted range is zero value of the type no check will be performed since
// this communicates that the caller is not interested on validating the range.
func AssertEqualRanges(t *testing.T, got, want info.Range, fmtargs ...any) {
	t.Helper()

	if isZeroRange(want) {
		return
	}

	msg := prefixer(fmtargs...)

	assert.EqualStrings(t, want.HostPath(), got.HostPath(), msg("host path mismatch"))
	AssertEqualPaths(t, got.Path(), want.Path(), msg("path mismatch"))
	AssertEqualPos(t, got.Start(), want.Start(), msg("start pos mismatch"))
	AssertEqualPos(t, got.End(), want.End(), msg("end pos mismatch"))
}

// AssertEqualPaths checks if two paths are equal.
func AssertEqualPaths(t *testing.T, got, want project.Path, fmtargs ...any) {
	t.Helper()

	if len(fmtargs) > 0 {
		assert.EqualStrings(t, want.String(), got.String(),
			fmt.Sprintf(fmtargs[0].(string), fmtargs[1:]...))
	} else {
		assert.EqualStrings(t, want.String(), got.String())
	}
}

// AssertReportHasError checks if the report has an error that matches
// the expected error.
func AssertReportHasError(t *testing.T, report *report.Report, err error) {
	t.Helper()
	// Most of this assertion behavior is due to making it easier to
	// refactor the tests to the new report design on code generation.
	// It is non ideal but it made the change radius smaller.
	// Can be improved further in the future.

	if err == nil {
		if len(report.Failures) > 0 {
			t.Fatalf("wanted no error but got failures: %v", report.Failures)
		}
		return
	}

	// Just checking if at least one of the errors match is exactly
	// what we were doing since before we had a chain of errors
	// and only checked for a match inside. This is non-ideal so in
	// the future lets match expectations with precision.
	if errors.Is(report.BootstrapErr, err) {
		return
	}
	for _, failure := range report.Failures {
		if errors.Is(failure.Error, err) {
			return
		}
	}
	t.Fatalf("unable to find match for %v on report:\n%s", err, report)
}

// AssertEqualReports checks if the got report is equal to the want report.
func AssertEqualReports(t *testing.T, got *report.Report, wantVal report.Report) {
	t.Helper()

	want := &wantVal

	// WHY: we can't just use cmp.Diff since the errors included on the Report
	// are not comparable and may contain unexported fields (depending on how errors are built)

	errtest.Assert(t, got.BootstrapErr, want.BootstrapErr)

	if diff := cmp.Diff(got.Successes, want.Successes, cmp.AllowUnexported(project.Path{})); diff != "" {
		t.Errorf("success results differs: got(-) want(+)")
		t.Error(diff)
	}

	assert.EqualInts(t,
		len(want.Failures),
		len(got.Failures),
		"unmatching failures: want:\n%s\ngot:\n%s\n", want, got)

	for i, gotFailure := range got.Failures {
		wantFailure := want.Failures[i]

		if diff := cmp.Diff(gotFailure.Result, wantFailure.Result,
			cmp.AllowUnexported(project.Path{})); diff != "" {
			t.Errorf("failure result differs: got(-) want(+)")
			t.Fatal(diff)
		}

		errtest.Assert(t, gotFailure.Error, wantFailure.Error)
	}
}

func assertAssertsBlock(t *testing.T, got, want []hcl.AssertConfig, ctx string) {
	t.Helper()

	if len(got) != len(want) {
		t.Fatalf("%s: got %d assert blocks, want %d", ctx, len(got), len(want))
	}

	for i, g := range got {
		w := want[i]
		newctx := fmt.Sprintf("%s: assert %d", ctx, i)
		AssertEqualRanges(t, g.Range, w.Range, "%s: range mismatch", newctx)
		assert.EqualStrings(t,
			exprAsStr(t, w.Assertion), exprAsStr(t, g.Assertion),
			"%s: assertion expr mismatch", newctx)
		assert.EqualStrings(t,
			exprAsStr(t, w.Message), exprAsStr(t, g.Message),
			"%s: message expr mismatch", newctx)
		assert.EqualStrings(t,
			exprAsStr(t, w.Warning), exprAsStr(t, g.Warning),
			"%s: warning expr mismatch", newctx)
	}
}

func exprAsStr(t *testing.T, expr hhcl.Expression) string {
	t.Helper()

	if expr == nil {
		return ""
	}

	tokens := ast.TokensForExpression(expr)
	return string(tokens.Bytes())
}

func assertTerramateBlock(t *testing.T, got, want *hcl.Terramate) {
	t.Helper()

	if want == got {
		// same pointer, or both nil
		return
	}

	if (want == nil) != (got == nil) {
		t.Fatalf("terramate: want[%v] != got[%v]", want, got)
	}

	if want == nil {
		t.Fatalf("want[nil] but got[%+v]", got)
	}

	assert.EqualStrings(t, want.RequiredVersion, got.RequiredVersion,
		"required_version mismatch")

	if (want.Config == nil) != (got.Config == nil) {
		t.Fatalf("want.Config[%+v] != got.Config[%+v]",
			want.Config, got.Config)
	}

	assertTerramateConfigBlock(t, got.Config, want.Config)
}

func assertTerramateConfigBlock(t *testing.T, got, want *hcl.RootConfig) {
	t.Helper()

	if want == nil {
		return
	}

	if (want.Git == nil) != (got.Git == nil) {
		t.Fatalf(
			"want.Git[%+v] != got.Git[%+v]",
			want.Git,
			got.Git,
		)
	}

	if want.Git != nil {
		if *want.Git != *got.Git {
			t.Fatalf("want.Git[%+v] != got.Git[%+v]", want.Git, got.Git)
		}
	}

	if !slices.Equal(want.Experiments, got.Experiments) {
		t.Fatalf("want.Experiments[%+v] != got.Experiments[%+v]", want.Experiments, got.Experiments)
	}

	assertTerramateRunBlock(t, got.Run, want.Run)
	assertTerramateCloudBlock(t, got.Cloud, want.Cloud)
}

func assertGenHCLBlocks(t *testing.T, got, want []hcl.GenHCLBlock) {
	t.Helper()

	// We don't have a good way to compare all contents for now
	assert.EqualInts(t, len(want), len(got), "genhcl blocks differ in len")

	for i, gotBlock := range got {
		wantBlock := want[i]
		AssertEqualRanges(t, gotBlock.Range, wantBlock.Range, "genhcl range differs")
		assert.EqualStrings(t, wantBlock.Label, gotBlock.Label, "genhcl label differs")
		assertAssertsBlock(t, gotBlock.Asserts, wantBlock.Asserts, "genhcl asserts")
	}
}

func assertGenFileBlocks(t *testing.T, got, want []hcl.GenFileBlock) {
	t.Helper()

	// We don't have a good way to compare all contents for now
	assert.EqualInts(t, len(want), len(got), "genfile blocks differ in len")

	for i, gotBlock := range got {
		wantBlock := want[i]
		AssertEqualRanges(t, gotBlock.Range, wantBlock.Range, "genfile range differs")
		assert.EqualStrings(t, wantBlock.Label, gotBlock.Label, "genfile label differs")
		assertAssertsBlock(t, gotBlock.Asserts, wantBlock.Asserts, "genfile asserts")
	}
}

func assertScriptBlocks(t *testing.T, got, want []*hcl.Script) {
	t.Helper()

	if (got == nil) != (want == nil) {
		t.Fatalf("script: want[%+v] != got[%+v]", want, got)
	}

	if want == nil {
		return
	}

	assert.EqualInts(t, len(got), len(want), "script length mismatch")

	for i, g := range got {
		w := want[i]

		if w.Description != nil {
			assert.EqualStrings(t,
				exprAsStr(t, w.Description.Expr), exprAsStr(t, g.Description.Expr),
				"description expr mismatch")
		} else if g.Description != nil {
			t.Fatalf("got script.description[%s] but expected nil", exprAsStr(t, g.Description.Expr))

		}

		assert.IsTrue(t, slices.Equal(w.Labels, g.Labels),
			fmt.Sprintf("script label value mismatch: want[%#v], got [%#v]", w.Labels, g.Labels))

		assert.EqualInts(t, len(w.Jobs), len(g.Jobs), "script len(jobs) mismatch")
		for k, gotJob := range g.Jobs {
			wantJob := w.Jobs[k]

			if wantJob.Name != nil {
				assert.EqualStrings(t,
					exprAsStr(t, wantJob.Name.Expr),
					exprAsStr(t, gotJob.Name.Expr),
				)
			} else if gotJob.Name != nil {
				t.Fatalf("got job.name[%s] but expected nil", exprAsStr(t, gotJob.Name.Expr))
			}

			if wantJob.Description != nil {
				assert.EqualStrings(t,
					exprAsStr(t, wantJob.Description.Expr),
					exprAsStr(t, gotJob.Description.Expr),
				)
			} else if gotJob.Description != nil {
				t.Fatalf("got job.description[%s] but expected nil", exprAsStr(t, gotJob.Description.Expr))
			}

			if wantJob.Command != nil {
				assert.EqualStrings(t,
					exprAsStr(t, wantJob.Command.Expr),
					exprAsStr(t, gotJob.Command.Expr),
					"command mismatch")
			}

			if wantJob.Commands != nil {
				assert.EqualStrings(t,
					exprAsStr(t, wantJob.Commands.Expr),
					exprAsStr(t, gotJob.Commands.Expr),
					"commands mismatch")
			}

		}
	}

}

func assertTerramateRunBlock(t *testing.T, got, want *hcl.RunConfig) {
	t.Helper()

	if (want == nil) != (got == nil) {
		t.Fatalf("want.Run[%+v] != got.Run[%+v]", want, got)
	}

	if want == nil {
		return
	}

	assert.IsTrue(t, want.CheckGenCode == got.CheckGenCode,
		"want.Run.CheckGenCode %v != got.Run.CheckGenCode %v",
		want.CheckGenCode, got.CheckGenCode)

	if (want.Env == nil) != (got.Env == nil) {
		t.Fatalf(
			"want.Run.Env[%+v] != got.Run.Env[%+v]",
			want.Env,
			got.Env,
		)
	}

	if want.Env == nil {
		return
	}

	// There is no easy way to compare hclsyntax.Attribute
	// (or hcl.Attribute, or hclsyntax.Expression, etc).
	// So we do this hack in an attempt of comparing the attributes
	// original expressions (no eval involved).

	gotHCL := hclFromAttributes(t, got.Env.Attributes)
	wantHCL := hclFromAttributes(t, want.Env.Attributes)

	AssertDiff(t, gotHCL, wantHCL)
}

func assertTerramateCloudBlock(t *testing.T, got, want *hcl.CloudConfig) {
	t.Helper()

	if (want == nil) != (got == nil) {
		t.Fatalf("want.Cloud[%+v] != got.Cloud[%+v]", want, got)
	}

	if want == nil {
		return
	}

	if *want != *got {
		t.Fatalf("want.Cloud[%+v] != got.Cloud[%+v]", want, got)
	}
}

// hclFromAttributes ensures that we always build the same HCL document
// given an hcl.Attributes.
func hclFromAttributes(t *testing.T, attrs ast.Attributes) string {
	t.Helper()

	file := hclwrite.NewEmptyFile()
	body := file.Body()
	for _, attr := range attrs.SortedList() {
		body.SetAttributeRaw(attr.Name, ast.TokensForExpression(attr.Expr))
	}

	return string(file.Bytes())
}

// WriteRootConfig writes a basic terramate root config.
func WriteRootConfig(t testing.TB, rootdir string) {
	WriteFile(t, rootdir, "root.config.tm", `
terramate {
	required_version = "> 0.0.1"
	required_version_allow_prereleases = true
}
			`)
}

func removeTerramateHCLHeader(code string) string {
	lines := []string{}

	for _, line := range strings.Split(code, "\n") {
		if strings.HasPrefix(line, "// TERRAMATE") {
			continue
		}
		lines = append(lines, line)
	}

	return strings.Join(lines, "\n")
}

// prefixer ass the given fmtargs as a prefix of any string passed
// to the returned function, if any. If fmtargs is empty then no prefix is added.
func prefixer(fmtargs ...any) func(string) string {
	prefix := ""

	if len(fmtargs) > 0 {
		prefix = fmt.Sprintf(fmtargs[0].(string), fmtargs[1:]...)
	}

	return func(s string) string {
		if prefix != "" {
			return fmt.Sprintf("%s: %s", prefix, s)
		}
		return s
	}
}

func isZeroRange(r info.Range) bool {
	var zero info.Range
	return zero == r
}
